iT邦幫忙

2023 iThome 鐵人賽

DAY 19
0
Software Development

FastAPI 開發筆記:從新手到專家的成長之路系列 第 19

[Day 19] 錯誤處理 (二):流程優化 與 客製化 HTTPException

  • 分享至 

  • xImage
  •  

今天繼續介紹 HTTPException~

原則上,基於安全考量,我們並不希望 (或甚至可以說不能) 讓前端知道後端的錯誤訊息,這也是為什麼 FastAPI 預設的錯誤處理就是單純的回應 500 Internal server Error。但是,在 Log 中,我們卻是希望能夠有足夠多的錯誤資訊能被保留下來,方便後續找出錯誤訊息。

因此,當發生錯誤時,我們希望可以做到這幾件事:

  1. 能收集錯誤訊息在 log 中
  2. 前端不知道詳細的錯誤訊息,只知道發生錯誤
  3. 發生可預期的錯誤時,使用我們自定義的錯誤訊息;不可預期的錯誤則回傳 Internal server Error
  4. 希望能不要用預設的 Internal server Error

以下先用 print() 當作紀錄 Log

接下來讓我們看看幾種不同的做法。

這也是我在實際開發時的版本演進順序,也順便把當時的思路分享給大家

1. 在每支 API 用 try except 把整個函數概括住

# main.py
from fastapi import FastAPI, HTTPException, status

app = FastAPI()

@app.get("/")
async def hello():
    try:
        print(a)
        b = 1 / 0
    except NameError as e:    # 可預期的錯誤
        print("Error:", e)    # 紀錄可預期的錯誤的 log
        raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail="This is Value Error")
    except Exception as e:    # 非預期的錯誤
        print("Error:", e)    # 紀錄非預期的錯誤的 log
        raise HTTPException(status.HTTP_500_NOT_IMPLEMENTED, detail="Internal server Error")
    return {"message": "hello"}

這樣做的確可以達到我們要的效果,但是每個 API 都要寫一遍一模一樣的程式碼,很浪費開發時間又造成程式碼冗長。

2. 用 middleware 統一處理非預期錯誤,可預期錯誤不變

# main.py
from fastapi import FastAPI, HTTPException, status, Request
from fastapi.responses import JSONResponse

app = FastAPI()

@app.middleware("http")
async def get_request(request: Request, call_next):
    try:
        response = await call_next(request)
        return response
    except Exception as e:     # 非預期的錯誤
        print("Error:", e)     # 紀錄非預期的錯誤的 log
        return JSONResponse(
            status_code=500,
            content={"detail": "Internal Server Error"},
        )

@app.get("/")
async def hello():
    try:
        print(a)
        b = 1 / 0
    except NameError as e:    # 可預期的錯誤
        print("Error:", e)    # 紀錄可預期的錯誤的 log
        raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail="This is Value Error")

現在這樣好一些,但是一樣要在每個 API 寫類似的程式碼去紀錄 log

3. 新增 HTTPException 的 middleware,並在這層 middleware 去紀錄 log

# main.py
from fastapi import FastAPI, HTTPException, status, Request
from fastapi.responses import JSONResponse

app = FastAPI()

@app.middleware("http")
async def get_request(request: Request, call_next):
    try:
        response = await call_next(request)
        return response
    except Exception as e:     # 非預期的錯誤
        print("Error:", e)     # 紀錄非預期的錯誤的 log
        return JSONResponse(
            status_code=500,
            content={"detail": "Internal Server Error"},
        )

@app.exception_handler(HTTPException)
async def unicorn_exception_handler(request: Request, exc: HTTPException):
    print("Error:", exc.detail)   # 紀錄可預期的錯誤的 log,但已經不是系統的錯誤訊息了
    return JSONResponse(
        status_code=exc.status_code,
        content={"detail": exc.detail},
    )

@app.get("/")
async def hello():
    try:
        print(a)
        b = 1 / 0
    except NameError as e:   # 可預期的錯誤
        raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail="This is Value Error")

這個版本比較不會有重複的程式碼了,但是遇到另一個問題,傳遞到 exception_handler 的錯誤訊息只有 detail,系統的錯誤訊息是沒有被傳遞過去的。

如果將 detail 改成系統的錯誤訊息,則會有安全上的疑慮,因此不考慮。

有一個粗暴的做法,那就是將錯誤訊息和我們自定的訊息以某種固定格式相接在一起 (例如:中間用 ___ 這類不太會出現的字串隔開),再用 detail 送到 middleware 後做切割,就可以達到我們的目的了。

但這個方法實在太暴力了,說不定過一陣子就忘記當初在想什麼,因此後來決定來客製化 HTTPException,讓它可以同時夾帶兩者到 middleware。

4. 客製化 HTTPException

首先,先來看看我自訂的 HTTPException

from typing import Any, Dict, Union
from fastapi import HTTPException

class NewHTTPException(HTTPException):
    def __init__(self, status_code: int, detail: Any = None, headers: Union[Dict[str, Any], None] = None, msg: str = None) -> None:
        super().__init__(status_code, detail, headers)
        # frontend only gets detail
        # msg is for logging
        # if no msg, system will log detail
        if msg:
            self.msg = msg
        else:
            self.msg = detail

大部分的程式碼是從 HTTPException 的原始碼抄來的,改動的地方主要有兩個,一個是在 __init__ 的參數多了 msg,另一個則是這個物件多了 msg 屬性 (self.msg)。

接著再用 NewHTTPException 取代原本的 HTTPException

# main.py
from fastapi import FastAPI, HTTPException, status, Request
from fastapi.responses import JSONResponse
from typing import Any, Dict, Union

app = FastAPI()

class NewHTTPException(HTTPException):
    def __init__(self, status_code: int, detail: Any = None, headers: Union[Dict[str, Any], None] = None, msg: str = None) -> None:
        super().__init__(status_code, detail, headers)
        if msg:
            self.msg = msg
        else:
            self.msg = detail

@app.middleware("http")
async def get_request(request: Request, call_next):
    try:
        response = await call_next(request)
        return response
    except Exception as e:     # 非預期的錯誤
        print("Error:", e)     # 紀錄非預期的錯誤的 log
        return JSONResponse(
            status_code=500,
            content={"detail": "Internal Server Error"},
        )

@app.exception_handler(NewHTTPException)
async def unicorn_exception_handler(request: Request, exc: NewHTTPException):
    print("Error:", exc.msg)   # 紀錄可預期的錯誤的 log
    return JSONResponse(
        status_code=exc.status_code,
        content={"detail": exc.detail},
    )

@app.get("/")
async def hello():
    try:
        print(a)
        b = 1 / 0
    except NameError as e:   # 可預期的錯誤
        raise NewHTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail="This is Value Error", msg=str(e))

注意,還有兩個小地方有改動

  1. @app.exception_handler(NewHTTPException) 內的 log 有改成 exc.msg
  2. raise NewHTTPException() 內多了 msg=str(e)

以上,就是最後的樣子了

重點回顧

今天主要是分享整個錯誤處理的流程演進 (和心路歷程),以及說明客製化 HTTPException 的做法。另外,想補充一下,我覺得錯誤處理就是因為扯上 Log 才變複雜,但我又不想穿插著介紹,因此今天只好先偷渡一些 Log 的內容了 (希望不會影響大家的理解 XD),明天就會開始正式介紹 Log 系統了~

最後,如果大家對內容有疑問,或是有更好 (或更合理) 的做法,歡迎來討論~


上一篇
[Day 18] 錯誤處理 (一):HTTPException
下一篇
[Day 20] 日誌系統 (一):Logging
系列文
FastAPI 開發筆記:從新手到專家的成長之路30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言